Skip to content

feat: add experimental ACP mode (--experimental-acp)#186

Open
johnstcn wants to merge 4 commits intocj/refactor/event-emitterfrom
cj/exp/acp
Open

feat: add experimental ACP mode (--experimental-acp)#186
johnstcn wants to merge 4 commits intocj/refactor/event-emitterfrom
cj/exp/acp

Conversation

@johnstcn
Copy link
Member

@johnstcn johnstcn commented Feb 13, 2026

Depends on #185

Add support for Agent Control Protocol (ACP) as an alternative to terminal emulation. ACP uses JSON-RPC over stdin/stdout pipes.

  • Introduce AgentIO interface to abstract PTY vs ACP transports
  • Add ACPConversation implementing Conversation interface
  • Add --experimental-acp flag (mutually exclusive with --print-openapi)
  • Add e2e test with mock ACP agent

Created using Mux (Opus 4.5)

@johnstcn johnstcn self-assigned this Feb 13, 2026
@github-actions
Copy link

✅ Preview binaries are ready!

To test with modules: agentapi_version = "agentapi_186" or download from: https://github.com/coder/agentapi/releases/tag/agentapi_186

Add support for Agent Control Protocol (ACP) as an alternative to
terminal emulation. ACP uses JSON-RPC over stdin/stdout pipes.

- Introduce AgentIO interface to abstract PTY vs ACP transports
- Add ACPConversation implementing Conversation interface
- Add --experimental-acp flag (mutually exclusive with --print-openapi)
- Add e2e test with mock ACP agent
- Block `attach` when using --experimental-acp (no terminal)
- Update chat UI to show ACP tool calls

Other changes:
- chat: Fix redundant draft filtering from finally block

Created using Mux (Opus 4.5)
Comment on lines -307 to -309
setMessages((prevMessages) =>
prevMessages.filter((m) => !isDraftMessage(m))
);
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: this was causing a 'flicker' when sending a message in the UI

}

if status.ACPMode {
return xerrors.New("attach is not supported in ACP mode. The server is running with --experimental-acp which uses JSON-RPC instead of terminal emulation.")
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: It should be eventually supported but I'm not sure what form it should take yet.

Comment on lines +89 to +96
func (c *acpClient) RequestPermission(ctx context.Context, params acp.RequestPermissionRequest) (acp.RequestPermissionResponse, error) {
// Auto-approve all permissions for Phase 1
return acp.RequestPermissionResponse{
Outcome: acp.RequestPermissionOutcome{
Selected: &acp.RequestPermissionOutcomeSelected{OptionId: "allow"},
},
}, nil
}
Copy link
Member Author

@johnstcn johnstcn Feb 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

review: we need to add support for allow/deny; will be handled in a future PR

@johnstcn johnstcn marked this pull request as ready for review February 17, 2026 17:57
Copilot AI review requested due to automatic review settings February 17, 2026 17:57
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds experimental support for Agent Control Protocol (ACP) as an alternative transport to PTY-based terminal emulation. ACP uses JSON-RPC over stdin/stdout pipes for cleaner communication without terminal escape sequences.

Changes:

  • Introduces AgentIO interface abstraction for PTY vs ACP transports
  • Implements ACPConversation and ACPAgentIO for ACP protocol support
  • Adds --experimental-acp CLI flag with validation against --print-openapi
  • Adds Backend field to status API response and blocks attach command in ACP mode

Reviewed changes

Copilot reviewed 16 out of 17 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
x/acpio/acpio.go Core ACP I/O implementation with JSON-RPC connection handling
x/acpio/acp_conversation.go ACP conversation tracker with async message handling
x/acpio/acp_conversation_test.go Comprehensive unit tests for ACP conversation
lib/httpapi/setup.go ACP process setup and lifecycle management
lib/httpapi/server.go Server integration with transport abstraction
lib/httpapi/server_test.go Updated tests to use AgentIO interface
lib/httpapi/models.go Added Backend field to status response
cmd/server/server.go CLI flag and transport selection logic
cmd/attach/attach.go ACP mode detection and rejection for attach command
openapi.json API schema update with backend field
go.mod/go.sum Added acp-go-sdk dependency
lib/acp/doc.go Package documentation for ACP support
e2e/acp_echo.go Mock ACP agent for e2e testing
e2e/echo_test.go E2E test for ACP mode
e2e/testdata/acp_basic.json Test script data
chat/src/components/chat-provider.tsx Removed draft message cleanup (appears to be a bug)
Comments suppressed due to low confidence (1)

lib/httpapi/server_test.go:37

  • The tests don't set the Transport field in ServerConfig, which means it will be an empty string. The server code will default to PTY transport when config.Transport != "acp", but the Backend field in the status response will be an empty string instead of "pty". This could cause issues for clients that check the backend field.

Consider setting a default value "pty" for config.Transport when it's empty, or explicitly setting Transport in all tests.

	srv, err := httpapi.NewServer(ctx, httpapi.ServerConfig{
		AgentType:      msgfmt.AgentTypeClaude,
		AgentIO:        nil,
		Port:           0,
		ChatBasePath:   "/chat",
		AllowedHosts:   []string{"*"},
		AllowedOrigins: []string{"*"},
	})

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +104 to +120
go func() {
select {
case <-ctx.Done():
logger.Info("Context done, closing ACP agent")
_ = stdin.Close()
_ = stdout.Close()
// Try graceful shutdown first
_ = cmd.Process.Signal(syscall.SIGTERM)
// Force kill after timeout
time.AfterFunc(5*time.Second, func() {
_ = cmd.Process.Kill()
})
case <-done:
// Process exited normally, nothing to clean up
return
}
}()
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The cleanup goroutine in SetupACP has a potential race condition with the AfterFunc timer. If the context is cancelled, SIGTERM is sent, and then exactly 5 seconds later the cleanup goroutine receives from the done channel (normal exit), the AfterFunc will still fire and call Kill() on an already-exited process.

Consider using a sync mechanism to cancel the AfterFunc timer when the process exits normally, or checking if the process has exited before calling Kill().

Copilot uses AI. Check for mistakes.
Comment on lines 306 to 309
if (type === "user") {
setMessages((prevMessages) =>
prevMessages.filter((m) => !isDraftMessage(m))
);
setLoading(false);
}
}
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The removed lines filtered out draft messages before setting loading to false. Without this cleanup, draft messages (messages with undefined id) could remain in the messages array if an error occurs during the POST request. This could cause UI inconsistencies where draft/optimistic messages persist even though the actual message was never sent.

The draft message cleanup should be performed in the finally block (or elsewhere) to ensure consistency.

Copilot uses AI. Check for mistakes.
Comment on lines +106 to +115
case <-ctx.Done():
logger.Info("Context done, closing ACP agent")
_ = stdin.Close()
_ = stdout.Close()
// Try graceful shutdown first
_ = cmd.Process.Signal(syscall.SIGTERM)
// Force kill after timeout
time.AfterFunc(5*time.Second, func() {
_ = cmd.Process.Kill()
})
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the context is cancelled, the cleanup goroutine closes stdin/stdout pipes and signals SIGTERM to the process. However, the main goroutine (lines 185-194) is simultaneously calling acpResult.Wait() which calls cmd.Wait(). Closing the pipes while the ACP connection might still be active could cause the ACP SDK to encounter unexpected EOF errors or other I/O errors.

Consider coordinating the shutdown sequence so that the ACP connection can be gracefully closed before the pipes are closed, or ensure that the ACP SDK properly handles pipe closure during context cancellation.

Copilot uses AI. Check for mistakes.
Comment on lines +202 to +204
// Remove the empty streaming message on error
if len(c.messages) > 0 && c.messages[len(c.messages)-1].Role == st.ConversationRoleAgent &&
c.messages[len(c.messages)-1].Message == "" {
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When an error occurs, the code checks if the last message is an empty agent message before removing it. However, the check only verifies Message == "". If the agent has started streaming chunks before an error occurs, the message won't be empty and won't be removed, leaving a partial/incomplete agent message in the history.

Consider either removing the agent message regardless of its content on error, or marking it with an error state so the UI can indicate the message was incomplete.

Suggested change
// Remove the empty streaming message on error
if len(c.messages) > 0 && c.messages[len(c.messages)-1].Role == st.ConversationRoleAgent &&
c.messages[len(c.messages)-1].Message == "" {
// Remove the in-progress streaming agent message on error
if len(c.messages) > 0 && c.messages[len(c.messages)-1].Role == st.ConversationRoleAgent {

Copilot uses AI. Check for mistakes.
"textLen", len(text),
"rawDataLen", len(data))

// Use a timeout to prevent hanging indefinitely
Copy link

Copilot AI Feb 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ACPAgentIO stores the context passed to NewWithPipes and uses it later in Write() for creating the prompt timeout context. If the parent context is cancelled (e.g., server shutdown), any in-flight Write() call will use context.WithTimeout on an already-cancelled context, which will immediately return a cancelled error instead of the intended timeout behavior.

Consider checking if the stored context is already cancelled before using it, or document that Write() may fail with context.Canceled during shutdown.

Suggested change
// Use a timeout to prevent hanging indefinitely
// Use a timeout to prevent hanging indefinitely
if err := a.ctx.Err(); err != nil {
a.logger.Debug("Agent context already canceled, aborting prompt", "error", err)
return 0, err
}

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant